package eu.europeana.cloud.service.mcs.rest; import eu.europeana.cloud.common.model.File; import eu.europeana.cloud.common.model.Representation; import eu.europeana.cloud.service.mcs.RecordService; import eu.europeana.cloud.service.mcs.exception.CannotModifyPersistentRepresentationException; import eu.europeana.cloud.service.mcs.exception.FileNotExistsException; import eu.europeana.cloud.service.mcs.exception.RepresentationNotExistsException; import eu.europeana.cloud.service.mcs.exception.WrongContentRangeException; import eu.europeana.cloud.service.mcs.rest.exceptionmappers.UnitedExceptionMapper; import eu.europeana.cloud.service.mcs.rest.storage.selector.PreBufferedInputStream; import eu.europeana.cloud.service.mcs.rest.storage.selector.StorageSelector; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.glassfish.jersey.media.multipart.FormDataParam; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Scope; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.acls.model.MutableAclService; import org.springframework.stereotype.Component; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.HEAD; import javax.ws.rs.HeaderParam; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.StreamingOutput; import javax.ws.rs.core.UriInfo; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URI; import java.util.regex.Matcher; import java.util.regex.Pattern; import static eu.europeana.cloud.common.web.ParamConstants.*; import static eu.europeana.cloud.service.mcs.rest.storage.selector.PreBufferedInputStream.wrap; /** * Resource to manage representation version's files with their content. */ @Path("/records/{" + P_CLOUDID + "}/representations/{" + P_REPRESENTATIONNAME + "}/versions/{" + P_VER + "}/files/{" + P_FILENAME + ":(.+)?}") @Component @Scope("request") public class FileResource { private static final String HEADER_RANGE = "Range"; @Autowired private RecordService recordService; @Autowired private MutableAclService mutableAclService; @Autowired private Integer objectStoreSizeThreshold; private final String REPRESENTATION_CLASS_NAME = Representation.class.getName(); /** * Updates a file in a representation version. MD5 of * the uploaded data is returned as a tag. Consumes multipart content - form data: * <ul> * <li>{@value eu.europeana.cloud.common.web.ParamConstants#F_FILE_MIME} - * file mime type</li> * <li>{@value eu.europeana.cloud.common.web.ParamConstants#F_FILE_DATA} - * binary stream of file content (required).</li> * </ul> * *<strong>Write permissions required.</strong> *@summary Updates a file in a representation version * @param globalId cloud id of the record in which the file will be updated (required) * @param schema schema of representation (required) * @param version a specific version of the representation(required) * @param fileName the name of the file(required) * @param mimeType mime type of file * @param data binary stream of file content (required) * @return URI of the uploaded content file in content-location * @throws RepresentationNotExistsException representation does not exist in * specified version. * @throws CannotModifyPersistentRepresentationException specified * representation version is persistent and modifying its files is not * allowed. * @throws FileNotExistsException specified file does not exist. * @statuscode 204 object has been updated. */ @PUT @Consumes(MediaType.MULTIPART_FORM_DATA) @PreAuthorize("hasPermission(#globalId.concat('/').concat(#schema).concat('/').concat(#version)," + " 'eu.europeana.cloud.common.model.Representation', write)") public Response sendFile(@Context UriInfo uriInfo, @PathParam(P_CLOUDID) String globalId, @PathParam(P_REPRESENTATIONNAME) String schema, @PathParam(P_VER) String version, @PathParam(P_FILENAME) String fileName, @FormDataParam(F_FILE_MIME) String mimeType, @FormDataParam(F_FILE_DATA) InputStream data) throws RepresentationNotExistsException, CannotModifyPersistentRepresentationException, FileNotExistsException { ParamUtil.require(F_FILE_DATA, data); ParamUtil.require(F_FILE_MIME, mimeType); File f = new File(); f.setMimeType(mimeType); f.setFileName(fileName); PreBufferedInputStream prebufferedInputStream = wrap(data, objectStoreSizeThreshold); f.setFileStorage(new StorageSelector(prebufferedInputStream, mimeType).selectStorage()); // For throw FileNotExistsException if specified file does not exist. recordService.getFile(globalId, schema, version, fileName); recordService.putContent(globalId, schema, version, f, prebufferedInputStream); IOUtils.closeQuietly(prebufferedInputStream); EnrichUriUtil.enrich(uriInfo, globalId, schema, version, f); return Response.status(Response.Status.NO_CONTENT).location(f.getContentUri()).tag(f.getMd5()).build(); } /** * Returns file content. Basic support for HTTP "Range" header is * implemented for retrieving only a part of content . * (Description of Range header can be found in Hypertext Transfer Protocol * HTTP/1.1, <a * href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35">section * 14.35 Range</a>). For instance: * <ul> * <li><b>Range: bytes=10-15</b> - retrieve bytes from 10 to 15 of content * <li><b>Range: bytes=10-</b> - skip 10 first bytes of content * </ul> * * <strong>Read permissions required.</strong> * @summary get file contents from a representation version * @param globalId cloud id of the record (required). * @param schema schema of representation (required). * @param version a specific version of the representation(required). * @param fileName the name of the file(required). * @param range range of bytes to return (optional) * @return file content * @throws RepresentationNotExistsException * representation does not exist in the specified version. * @throws RepresentationNotExistsException representation does not exist in * the specified version. * @throws WrongContentRangeException wrong value in "Range" header * @throws FileNotExistsException representation version does not have file * with the specified name. */ @GET @PreAuthorize("hasPermission(#globalId.concat('/').concat(#schema).concat('/').concat(#version)," + " 'eu.europeana.cloud.common.model.Representation', read)") public Response getFile(@PathParam(P_CLOUDID) final String globalId, @PathParam(P_REPRESENTATIONNAME) final String schema, @PathParam(P_VER) final String version, @PathParam(P_FILENAME) final String fileName, @HeaderParam(HEADER_RANGE) String range) throws RepresentationNotExistsException, FileNotExistsException, WrongContentRangeException { // extract range final ContentRange contentRange; if (range == null) { contentRange = new ContentRange(-1L, -1L); } else { contentRange = ContentRange.parse(range); } // get file md5 if complete file is requested String md5 = null; String fileMimeType = null; Response.Status status; if (contentRange.isSpecified()) { status = Response.Status.PARTIAL_CONTENT; } else { status = Response.Status.OK; final File requestedFile = recordService.getFile(globalId, schema, version, fileName); md5 = requestedFile.getMd5(); if(StringUtils.isNotBlank(requestedFile.getMimeType())){ fileMimeType = requestedFile.getMimeType(); } } // stream output StreamingOutput output = new StreamingOutput() { @Override public void write(OutputStream output) throws IOException, WebApplicationException { try { recordService.getContent(globalId, schema, version, fileName, contentRange.start, contentRange.end, output); } catch (RepresentationNotExistsException ex) { throw new WebApplicationException(new UnitedExceptionMapper().toResponse(ex)); } catch (FileNotExistsException ex) { throw new WebApplicationException(new UnitedExceptionMapper().toResponse(ex)); } catch (WrongContentRangeException ex) { throw new WebApplicationException(new UnitedExceptionMapper().toResponse(ex)); } } }; return Response.status(status).entity(output).type(fileMimeType).tag(md5).build(); } /** * * Returns only HTTP headers for file request. * * @param uriInfo * @param globalId cloud id of the record (required). * @param schema schema of representation (required). * @param version a specific version of the representation(required). * @param fileName the name of the file(required). * * @return empty response with proper http headers * @summary get HTTP headers for file request * @throws RepresentationNotExistsException * @throws FileNotExistsException */ @HEAD @PreAuthorize("hasPermission(#globalId.concat('/').concat(#schema).concat('/').concat(#version)," + " 'eu.europeana.cloud.common.model.Representation', read)") public Response getFileHeaders(@Context UriInfo uriInfo, @PathParam(P_CLOUDID) final String globalId, @PathParam(P_REPRESENTATIONNAME) final String schema, @PathParam(P_VER) final String version, @PathParam(P_FILENAME) final String fileName) throws RepresentationNotExistsException, FileNotExistsException { URI requestUri = uriInfo.getRequestUri(); final File requestedFile = recordService.getFile(globalId, schema, version, fileName); String fileMimeType = null; String md5 = requestedFile.getMd5(); if (StringUtils.isNotBlank(requestedFile.getMimeType())) { fileMimeType = requestedFile.getMimeType(); } return Response.status(Response.Status.OK).type(fileMimeType).location(requestUri).tag(md5).build(); } /** * Deletes file from representation version. *<strong>Delete permissions required.</strong> * * @param globalId cloud id of the record (required). * @param schema schema of representation (required). * @param version a specific version of the representation(required). * @param fileName the name of the file(required). * * @throws RepresentationNotExistsException representation does not exist in * the specified version. * @throws FileNotExistsException representation version does not have file * with the specified name. * @throws CannotModifyPersistentRepresentationException specified * representation version is persistent and deleting its files is not * allowed. */ @DELETE @PreAuthorize("hasPermission(#globalId.concat('/').concat(#schema).concat('/').concat(#version)," + " 'eu.europeana.cloud.common.model.Representation', delete)") public void deleteFile(@PathParam(P_CLOUDID) final String globalId, @PathParam(P_REPRESENTATIONNAME) String schema, @PathParam(P_VER) String version, @PathParam(P_FILENAME) String fileName) throws RepresentationNotExistsException, FileNotExistsException, CannotModifyPersistentRepresentationException { recordService.deleteContent(globalId, schema, version, fileName); } /** * Description of Range header can be found in Hypertext Transfer Protocol * HTTP/1.1, <a * href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35">section * 14.35 Range</a>. */ static class ContentRange { private long start, end; private static final Pattern BYTES_PATTERN = Pattern.compile("bytes=(?<start>\\d+)[-](?<end>\\d*)"); ContentRange(long start, long end) { this.start = start; this.end = end; } boolean isSpecified() { return start != -1 || end != -1; } long getStart() { return start; } long getEnd() { return end; } static ContentRange parse(String range) throws WrongContentRangeException { long start, end; if (range == null) { throw new IllegalArgumentException("Range should not be null"); } Matcher rangeMatcher = BYTES_PATTERN.matcher(range); if (rangeMatcher.matches()) { try { start = Long.parseLong(rangeMatcher.group("start")); String endString = rangeMatcher.group("end"); end = endString.isEmpty() ? -1 : Long.parseLong(endString); } catch (NumberFormatException ex) { throw new WrongContentRangeException("Cannot parse range: " + ex.getMessage()); } } else { throw new WrongContentRangeException("Expected range header format is: " + BYTES_PATTERN.pattern()); } if (end != -1 && end < start) { throw new WrongContentRangeException("Range end must not be smaller than range start"); } return new ContentRange(start, end); } } }